summaryrefslogtreecommitdiff
path: root/app/api/auth/[...nextauth]/saml/utils.ts
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/auth/[...nextauth]/saml/utils.ts')
-rw-r--r--app/api/auth/[...nextauth]/saml/utils.ts175
1 files changed, 125 insertions, 50 deletions
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts
index 7dfe9581..73c00bf6 100644
--- a/app/api/auth/[...nextauth]/saml/utils.ts
+++ b/app/api/auth/[...nextauth]/saml/utils.ts
@@ -6,11 +6,12 @@ import {
import {
getSPMetadata,
} from "@/lib/saml/sp-metadata";
+import { debugLog, debugError, debugSuccess, debugProcess, debugMock } from '@/lib/debug-utils';
export interface SAMLProfile {
nameID?: string;
nameIDFormat?: string;
- attributes?: Record<string, string[]>;
+ attributes?: Record<string, string | string[]>; // 문자열 또는 배열 모두 지원
[key: string]: unknown;
}
@@ -100,6 +101,12 @@ export async function createAuthnRequest(): Promise<string> {
"use server";
console.log("SSO STEP 2: Create AuthnRequest");
+
+ // Mock IdP 모드 체크
+ if (process.env.SAML_MOCKING_IDP === 'true') {
+ debugMock("Mock IdP mode enabled - simulating SAML response");
+ return createMockSAMLFlow();
+ }
try {
const config = createSAMLConfig();
@@ -170,7 +177,7 @@ export async function createAuthnRequest(): Promise<string> {
);
try {
- const zlib = require("zlib");
+ const zlib = await import("zlib");
const decompressed = zlib
.inflateRawSync(base64DecodedBuffer)
.toString("utf-8");
@@ -182,9 +189,9 @@ export async function createAuthnRequest(): Promise<string> {
// XML 구조 분석
const xmlLines = decompressed
.split("\n")
- .filter((line) => line.trim());
+ .filter((line: string) => line.trim());
console.log("XML 구조 요약:");
- xmlLines.forEach((line, index) => {
+ xmlLines.forEach((line: string, index: number) => {
const trimmed = line.trim();
if (
trimmed.includes("<saml") ||
@@ -224,7 +231,7 @@ export async function createAuthnRequest(): Promise<string> {
` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}`
);
} catch (inflateError) {
- console.log("❌ Deflate 압축 해제 실패:", inflateError.message);
+ console.log("❌ Deflate 압축 해제 실패:", (inflateError as Error).message);
console.log(
" 원본 바이너리 데이터 (hex):",
base64DecodedBuffer.toString("hex").substring(0, 100) + "..."
@@ -232,11 +239,11 @@ export async function createAuthnRequest(): Promise<string> {
}
}
} catch (decodeError) {
- console.log("❌ Base64 디코딩 실패:", decodeError.message);
+ console.log("❌ Base64 디코딩 실패:", (decodeError as Error).message);
}
}
} catch (analysisError) {
- console.log("⚠️ SAML AuthnRequest 분석 중 오류:", analysisError.message);
+ console.log("⚠️ SAML AuthnRequest 분석 중 오류:", (analysisError as Error).message);
}
console.log("✅ SAML AuthnRequest URL generated:", {
@@ -271,9 +278,15 @@ export async function validateSAMLResponse(
timestamp: new Date().toISOString(),
});
+ // Mock IdP 모드 체크
+ if (process.env.SAML_MOCKING_IDP === 'true') {
+ debugMock("Mock IdP mode - returning mock SAML profile");
+ return createMockSAMLProfile(samlResponse);
+ }
+
// 실제 SAML 검증 수행 (기본값)
console.log(
- "🔐 Using Real SAML validation (SAML_USE_MOCKUP=false or not set)"
+ "🔐 Using Real SAML validation (SAML_MOCKING_IDP=false or not set)"
);
try {
@@ -293,11 +306,11 @@ export async function validateSAMLResponse(
throw new Error("No profile returned from SAML validation");
}
- // SAMLProfile 형태로 변환
+ // SAMLProfile 형태로 변환 (타입 안전성 확보)
const samlProfile: SAMLProfile = {
- nameID: profile.nameID,
- nameIDFormat: profile.nameIDFormat,
- attributes: profile.attributes || {},
+ nameID: profile.nameID as string | undefined,
+ nameIDFormat: profile.nameIDFormat as string | undefined,
+ attributes: profile.attributes as Record<string, string | string[]> | undefined,
};
console.log("✅ Real SAML Profile validated successfully:", {
@@ -332,71 +345,133 @@ export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser {
attributes: profile.attributes,
});
+ // SAML attributes는 문자열 또는 배열 형태일 수 있음
+ const extractAttributeValue = (key: string): string | undefined => {
+ const value = profile.attributes?.[key];
+ if (Array.isArray(value)) {
+ return value.length > 0 ? value[0] : undefined;
+ }
+ return typeof value === 'string' ? value : undefined;
+ };
+
// 기본적으로 nameID를 사용하거나 attributes에서 추출
- const id =
- profile.nameID ||
- profile.attributes?.uid?.[0] ||
- profile.attributes?.employeeNumber?.[0] ||
- "";
- const email =
- profile.attributes?.email?.[0] ||
- profile.attributes?.mail?.[0] ||
- profile.nameID ||
- "";
- // UTF-8 이름 처리 개선
- let name =
- profile.attributes?.displayName?.[0] ||
- profile.attributes?.cn?.[0] ||
- profile.attributes?.name?.[0] ||
- (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0]
- ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0]
- : "") ||
- "";
+ const id = profile.nameID || extractAttributeValue('id') || extractAttributeValue('sub');
+ const email = extractAttributeValue('email') || extractAttributeValue('emailAddress');
+ const name = extractAttributeValue('name') || extractAttributeValue('displayName') || extractAttributeValue('cn');
+
+ // 필수 필드 검증
+ if (!id) {
+ throw new Error('SAML profile missing required field: id (nameID)');
+ }
+ if (!email) {
+ throw new Error('SAML profile missing required field: email');
+ }
+ if (!name) {
+ throw new Error('SAML profile missing required field: name');
+ }
// UTF-8 문자열 정규화 및 검증
- if (name && typeof name === "string") {
- name = name.normalize("NFC").trim();
-
- // 한글이 깨진 경우 감지 및 로그
- const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name);
- if (hasInvalidChars) {
- console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
- originalName: name,
- charCodes: [...name].map((c) => c.charCodeAt(0)),
- hexDump: [...name]
- .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
- .join(""),
- });
- }
+ const normalizedName = name.normalize("NFC").trim();
+
+ // 한글이 깨진 경우 감지 및 로그
+ const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(normalizedName);
+ if (hasInvalidChars) {
+ console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
+ originalName: name,
+ normalizedName,
+ charCodes: [...normalizedName].map((c) => c.charCodeAt(0)),
+ hexDump: [...normalizedName]
+ .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join(""),
+ });
}
- // 회사 정보는 SSO 로그인 시 없음
+ // 회사 정보는 SSO 로그인 시 없음 (evcp 도메인)
const companyId = undefined;
const techCompanyId = undefined;
const domain = 'evcp';
- const user = {
+ const user: SAMLUser = {
id,
email,
- name: name.trim(),
+ name: normalizedName,
companyId,
techCompanyId,
domain,
};
- console.log("👤 Mapped user object:", user);
+ console.log("👤 Mapped user object:", JSON.stringify(user));
return user;
}
+// Mock SAML 플로우 생성 (테스트용)
+function createMockSAMLFlow(): string {
+ debugMock("Creating mock SAML flow...");
+
+ // Mock 모드에서는 Mock IdP 엔드포인트로 리다이렉션
+ const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
+ const mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`;
+
+ debugMock("Mock SAML Flow - redirecting to Mock IdP:", mockIdpUrl);
+
+ return mockIdpUrl;
+}
+
+// Mock SAML Profile 생성 (테스트용)
+function createMockSAMLProfile(samlResponse: string): SAMLProfile {
+ console.log("🎭 Creating mock SAML profile from response...");
+
+ try {
+ // SAML Response가 우리가 생성한 Mock인지 확인
+ const decodedXML = Buffer.from(samlResponse, 'base64').toString('utf-8');
+ const isMockResponse = decodedXML.includes('MockIdP');
+
+ if (!isMockResponse) {
+ console.warn("⚠️ Mock mode enabled but received non-mock SAML Response");
+ }
+
+ console.log("🎭 Mock SAML XML preview:", decodedXML.substring(0, 200) + "...");
+ } catch (error) {
+ console.warn("⚠️ Could not decode SAML Response for mock analysis:", (error as Error).message);
+ }
+
+ // Mock SAML Profile 반환 (실제 SAML Response와 일치하도록 문자열 형태)
+ const mockProfile: SAMLProfile = {
+ nameID: "testuser@samsung.com",
+ nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress",
+ attributes: {
+ email: "testuser@samsung.com",
+ name: "테스트 사용자",
+ displayName: "Test User Samsung",
+ // 추가 테스트 속성들
+ department: "개발팀",
+ employeeId: "TEST001",
+ mobile: "010-1234-5678"
+ }
+ };
+
+ console.log("🎭 Mock SAML Profile created:", {
+ nameID: mockProfile.nameID,
+ nameIDFormat: mockProfile.nameIDFormat,
+ attributeCount: Object.keys(mockProfile.attributes || {}).length,
+ attributes: Object.keys(mockProfile.attributes || {}),
+ timestamp: new Date().toISOString(),
+ });
+
+ return mockProfile;
+}
+
// SAML 로그아웃 URL 생성 (서버 액션)
// 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠.
export async function createLogoutRequest(nameID: string): Promise<string> {
"use server";
const saml = new SAML(createSAMLConfig());
+ // Profile 객체 형태로 전달
+ const profile = { nameID };
return await saml.getLogoutUrlAsync(
- nameID,
+ profile,
"", // RelayState
{
nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",